/**
 * @fileoverview
 * Periodic date series.
 */

/**
 * Given a start date and a specification for a periodic function, produce
 * an object with the following methods:
 *
 * 1. generator - returns a function that each time it is called returns the
 *    next date in the series.
 * 2. toString - returns a human language description of the series.
 */
function dateSeries(firstDate, seriesSpec) {
  if (!(firstDate instanceof Date)) { throw new Error(firstDate); }

  // The day of the week on which the week starts.
  // Sunday in the US, Monday in most of the rest of the world.
  // Regardless of WKST, days of the week are represented as integers in
  // the range [0=Sunday .. 6=Saturday] as does Date.prototype.getUTCDay().
  var wkst = 0;
  // The days of the week.
  var daysOfWeek = [];
  // The frequency of iteration.
  // FREQ_WEEKLY to repeat weekly, FREQ_MONTHLY for monthly, etc.
  var freq = FREQ_YEARLY;
  // The number of steps of length frequency that we should step forward
  // each turn of the counter.
  var interval = 1;
  // The days of the month in [1..31] for exact dates, and [-1..-30] for days
  // of the month measured from the end of the month.
  var daysOfMonth = [];
  // The months of the year.
  var months = [];

  function bitset(arr) {
    var bits = 0;
    for (var i = 0, n = arr.length; i < n; ++i) {
      bits = bits | (1 << arr[i]);
    }
    return bits;
  }

  if (seriesSpec) (function () {
    function _int(x, min, max, k) {
      if (x !== (x | 0)
          || (min !== void 0 && min > x) 
          || (max !== void 0 && max < x)) {
        throw new Error(k + "=" + x);
      }
      return x;
    }

    function _ints(x, min, max, k) {
      var n = _int(x.length, 0, void 0, k + ".length");
      for (var i = 0; i < n; ++i) {
        _int(x[i], min, max);
      }
      return x;
    }

    // Unpack the specification.
    for (var k in seriesSpec) {
      if (!Object.prototype.hasOwnProperty.call(seriesSpec, k)) { continue; }
      var v = seriesSpec[k];
      switch (k) {
        case 'wkst': wkst = _int(v, 0, 6, k); break;
        case 'daysOfWeek': daysOfWeek = uniqInts(_ints(v, 0, 6, k)); break;
        case 'freq': freq = _int(v, FREQ_DAILY, FREQ_YEARLY, k); break;
        case 'interval': interval = _int(v, 1, void 0, k); break;
        case 'daysOfMonth': daysOfMonth = uniqInts(_ints(v, -31, 31, k)); break;
        case 'months': months = uniqInts(_ints(v, 0, 11, k)); break;
        default: throw new Error(k);
      }
    }
    
    // Release for GC.
    seriesSpec = null;
  })();

  // Infer defaults.
  if (!daysOfWeek.length && !daysOfMonth.length && !months.length) {
    switch (freq) {
      case FREQ_DAILY:
        if (interval !== 1) {
          daysOfWeek = [0,1,2,3,4,5,6];
        }
        break;
      case FREQ_WEEKLY:
        daysOfWeek = [firstDate.getUTCDay()];
        break;
      case FREQ_MONTHLY:
        daysOfMonth = [firstDate.getUTCDate()];
        break;
      case FREQ_YEARLY:
        months = [firstDate.getUTCMonth()];
        daysOfMonth = [firstDate.getUTCDate()];
        break;
    }
  }

  var addPeriod = function (d, freq, n) {
    return this.addPeriod(d, freq, n, wkst);
  };

  function nextWeekDay(d, wday) {
    var year = d.getUTCFullYear();
    var month = d.getUTCMonth();
    var day = d.getUTCDate();
    day -= (7 + (d.getUTCDay() - wkst)) % 7;  // Shift to start of week.
    day += (7 + (wday - wkst)) % 7;  // Shift forward to next wday.
    return date(year, month, day);
  }

  function generator() {
    var pending = [];
    var anchor = firstDate;

    var enumerateMonths = months.length ?
       function (start, end, out) {
         var year = start.getUTCFullYear();
         while (true) {
           for (var i = 0, n = months.length; i < n; ++i) {
             var d = date(year, months[i], 1);
             if (d >= end) { return; }
             out.push(d);
           }
           ++year;
         }
       } :
       function (start, end, out) {
         start = date(start.getUTCFullYear(), start.getUTCMonth(), 1);
         while (start < end) {
           out.push(start);
           var next = addPeriod(start, FREQ_MONTHLY, 1);
           start = next;
         }
       };
    var enumerateDays = freq == FREQ_DAILY && interval !== 1 ?
       (function () {
         var dowBits = bitset(daysOfWeek) || ((1 << 7) - 1);
         function matchesDaysOfMonth(d) {
           var dom = d.getUTCDate();
           var nDays = countDaysInMonth(d.getUTCFullYear(), d.getUTCMonth());
           for (var i = 0, n = daysOfMonth.length; i < n; ++i) {
             var domf = daysOfMonth[i];
             if (domf < 0) { domf += nDays + 1; }
             if (domf === dom) { return true; }
           }
           return false;
         }
         return function (year, month, start, end, out) {
           while (start < end) {
             if (dowBits & (1 << start.getDay())) {
               if (!daysOfMonth.length || matchesDaysOfMonth(start)) {
                 out.push(start);
               }
             }
             start = addPeriod(start, FREQ_DAILY, interval);
           }
         };
       })() : daysOfMonth.length ?
       (function () {
         var dowBits = bitset(daysOfWeek) || ((1 << 7) - 1);
         return function (year, month, start, end, out) {
           var nDays = countDaysInMonth(year, month);
           var absDays = [], i, n;
           for (i = 0, n = daysOfMonth.length; i < n; ++i) {
             var dom = daysOfMonth[i];
             if (dom < 0) { dom += nDays + 1; }
             if (dom <= 0 || dom > nDays) { continue; }
             absDays.push(dom);
           }
           absDays = uniqInts(absDays);
           for (i = 0, n = absDays.length; i < n; ++i) {
             var dom = absDays[i];
             var d = date(year, month, dom);
             if (d >= end) { return; }
             if (d >= start && (dowBits & (1 << d.getUTCDay()))) {
               out.push(d);
             }
           }
         };
       })() :
       function (year, month, start, end, out) {
         var d = year === start.getUTCFullYear()
             && month === start.getUTCMonth()
             ? start : date(year, month, 1);
         while (true) {
           for (var i = 0, n = daysOfWeek.length; i < n; ++i) {
             var day = nextWeekDay(d, daysOfWeek[i]);
             if (day.getUTCMonth() !== month || day.getUTCFullYear() !== year
                 || day >= end) {
               return;
             }
             if (day >= start) {
               out.push(day);
             }
           }
           var next = addPeriod(d, FREQ_WEEKLY, 1);
           d = next;
           if (d >= end) { return; }
         }
       };
    function enumerate(start, end, pending) {
      var monthsOut = [];
      enumerateMonths(start, end, monthsOut);
      for (var i = 0, nMonths = monthsOut.length; i < nMonths; ++i) {
        var month = monthsOut[i];
        enumerateDays(
          month.getUTCFullYear(), month.getUTCMonth(), start, end, pending);
      }
    }

    return function () {
      var tries = 0;
      while (++tries < 100) {
        if (pending.length) {
          return pending.shift();
        }
        // Calculate next anchor.
        var start = anchor;
        if (!start) { continue; }
        var end = addPeriod(start, freq, 1);
        if (!end) { continue; }

        enumerate(start, end, pending);
        anchor = addPeriod(anchor, freq, interval);
      }
      throw new Error("No valid dates after " + anchor);
    };
  }

  function str() {
    var s = "Every ";
    if (interval !== 1) { s += interval + " "; }
    switch (freq) {
    case FREQ_DAILY: s += "day"; break;
    case FREQ_WEEKLY: s += "week"; break;
    case FREQ_MONTHLY: s += "month"; break;
    case FREQ_YEARLY: s += "year"; break;
    }
    if (interval !== 1) { s += "s"; }
    if (daysOfWeek.length) {
      var dowStr = "" + daysOfWeek;
      if (dowStr !== "0,1,2,3,4,5,6") {
        s += " on ";
        if (dowStr === "0,6") {
          s += "weekends";
        } else if (dowStr === "1,2,3,4,5") {
          s += "weekdays";
        } else {
          for (var i = 0, n = daysOfWeek.length; i < n; ++i) {
            if (i) { s += ", "; if (i+1 == n) { s += "and "; } }
            s += dayOfWeekPrefixes[daysOfWeek[i]] + "days";
          }
        }
        if (daysOfMonth.length) {
          s += " that are";
        }
      }
    }
    if (daysOfMonth.length) {
      s += " on ";
      var domPresentationOrder = daysOfMonth.slice(0);
      domPresentationOrder.sort(
        function (a, b) {
          return ((a < 0) - (b < 0)) || (a - b);
        });
      for (var i = 0, n = domPresentationOrder.length; i < n; ++i) {
        if (i) { s += ", "; if (i+1 == n) { s += "and "; } }
        var dom = domPresentationOrder[i];
        if (dom === -1) { s += "the last"; continue; }
        var absDay = Math.abs(dom);
        var dayMod10 = (absDay % 10);
        var dayMod100 = (absDay % 100);
        var ordinal = absDay;
        switch (dayMod100 - dayMod10 !== 10 ? dayMod10 : 0) {
          case 1: ordinal += "st"; break;
          case 2: ordinal += "nd"; break;
          case 3: ordinal += "rd"; break;
          default: ordinal += "th"; break;
        }
        s += "the " + ordinal + (dom < 0 ? " to last" : "");
      }
      if (domPresentationOrder[domPresentationOrder.length-1] < 0) {
        s += " day";
      }
      if (!months.length && freq != FREQ_MONTHLY) { s += " of the month"; }
    }
    if (months.length) {
      s += " of ";
      for (var i = 0, n = months.length; i < n; ++i) {
        if (i) { s += ", "; if (i+1 == n) { s += "and "; } }
        s += monthNames[months[i]];
      }
    }
    return s;
  }

  return { generator: generator, toString: str };
}
